<?php

namespace cf;

class ArrayOfItems
	implements \IteratorAggregate
{
	public function getIterator() 
	{
		return new \ArrayIterator($this->items);
	}

	public function toArray()
	{
		return $this->items;
	}
	
	public function is_empty()
	{
		return empty($this->items);
	}
	
	public function size()
	{
		return count($this->items);
	}
	
	public function &at($idx)
	{
		return $this->items[$idx];
	}
	
	public function append(ArrayOfItems $items)
	{
		foreach ($items as $item) {
			$this->add($item);
		}
	}
	
	public function erase($idx)
	{
		unset($this->items[$idx]);
	}
	
	public function __toString()
	{
		$s = '';
		foreach ($this->items as $item) {
			$s .= $item->__toString() . "<br>\n";
		}
		return $s;
   }


	
	protected function add($item) 
	{
		$this->items[] = $item;
	}
	
	protected $items = array();
}



class Group
{
	const rootId = null;
	
	static public function get($id)
	{
		return new Group(String::lastWord($id,'/'));
	}
	
	function __construct($id)
	{
		$this->id = self::toID($id);
		
		static $q = null;
		createStaticQuery($q,"
			SELECT parent_id, name, hidden, code, image, short_descr, page_title, page_keywords, page_descr, 
				(SELECT COUNT(*) FROM cf_groups WHERE parent_id=:id) AS children_count
			FROM cf_groups
			WHERE id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No Group with id [{$this->id}]");
		}
		
		$this->parentId = $q->at('parent_id');
		$this->name = $q->at('name');
		$this->hidden = (bool)$q->at('hidden');
		$this->code = trim($q->at('code'));
		$this->image = $q->at('image');
		$this->shortDescr = $q->at('short_descr');
		$this->childrenCount = $q->at('children_count');
		$this->pageTitle = $q->at('page_title');
		$this->pageKeywords = $q->at('page_keywords');
		$this->pageDescr = $q->at('page_descr');
		
		$q->close();
	}
	
	public function id() 
	{
		return $this->id;
	}
	
	/** @return  string CODE or numeric ID */
	public function code() 
	{
		return strlen($this->code) ? $this->code : $this->id;
	}
	
	/**
		@param $id  string CODE or numeric ID
		@return numeric ID
	*/
	static public function toID($id)
	{
		if (!$id) {		//root group
			return null;
		}
		
		if (is_numeric($id)) {
			return (int)$id;
		}
		
		static $q = null;
		createStaticQuery($q,"
			SELECT id FROM cf_groups WHERE code=:code
		");
		$q->setParam('code',trim($id));
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("Invalid group code: [$id]");
		}
		$id = (int)$q->at('id');
		$q->close();
		
		return $id;
	}

	public function parentId() 
	{
		return $this->parentId;
	}

	public function name() 
	{
		return $this->name;
	}
	
	public function hidden() 
	{
		return $this->hidden;
	}
	
	public function descr() 
	{
		if (!is_null($this->descr)) {
			return $this->descr;
		}
		
		static $q = null;
		createStaticQuery($q,"
			SELECT descr
			FROM cf_groups
			WHERE id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No Group with id [{$this->id}]");
		}
		$this->descr = $q->at('descr');
		$q->close();
	
		return $this->descr;
	}
	
	public function image() 
	{
		return $this->image;
	}

	public function shortDescr() 
	{
		return $this->shortDescr;
	}
	
	public function childrenCount()
	{
		return $this->childrenCount;
	}
	
	public function pageTitle() 
	{
		return $this->pageTitle;
	}

	public function pageKeywords() 
	{
		return $this->pageKeywords;
	}

	public function pageDescr() 
	{
		return $this->pageDescr;
	}
	
	public function nestingDepth()
	{
		if (!is_null($this->nestingDepth)) {
			return $this->nestingDepth;
		}
		
		$this->nestingDepth = 0;
		
		$id = $this->parentId();
		while ($id != Group::rootId)
		{
			++$this->nestingDepth;
			$gr = new Group($id);
			$id = $gr->parentId();
		}
		
		return $this->nestingDepth;
	}
	
	public function hasProducts()
	{
		if (!is_null($this->hasProducts)) {
			return $this->hasProducts;
		}
		
		static $q = null;
		createStaticQuery($q,"
			SELECT product_id
			FROM cf_products_in_groups
			WHERE group_id=:id
			LIMIT 1
		");
		
		$q->setParam('id',$this->id);
		$q->execute();
		$this->hasProducts = (bool)$q->fetch();
		$q->close();
	
		return $this->hasProducts;
	}
	
	public function attributes()
	{
		return new GroupAttributes($this->id);
	}
	
	public function __toString() 
	{
		return "[{$this->id()}] {$this->name()}";
	}
	
	public function link()
	{
		$ancestors = new AncestorGroups($this->id());
		$codes = array();
		foreach ($ancestors as $group)	{
			$codes[] = $group->code();
		}
		return '/'.implode('/',array_merge($codes,array($this->code())));
	}
	
	public function isAncestorOf($id)
	{
		$allAncestors = new AncestorGroups(self::toID($id));
		return in_array($this->id(), $allAncestors->IDs());
	}

	public function isDescendantOf($id)
	{
		$allAncestors = new AncestorGroups($this->id);
		return in_array(self::toID($id), $allAncestors->IDs());
	}	

	
	private $id = null;
	private $code = null;
	private $parentId = null;
	private $name = '';
	private $descr = null;
	private $image = null;
	private $shortDescr = null;
	private $hidden = null;
	private $nestingDepth = null;
	private $hasProducts = null;
	private $pageTitle = null;
	private $pageKeywords = null;
	private $pageDescr = null;
	private $childrenCount = null;
}


class Groups
	extends ArrayOfItems
{
	public function names()
	{
		$names = array();
		foreach ($this->items as $group)
		{
			$names[] = $group->name();
		}
		return $names;
	}
	
	public function IDs()
	{
		$ids = array();
		foreach ($this->items as $group)
		{
			$ids[] = $group->id();
		}
		return $ids;
	}
	
	public function get($id) 
	{
		$id = Group::toID($id);
		foreach ($this->items as $group) 
		{
			if ($group->id() == $id) {
				return $group;
			}
		}
		return false;
	}
	
	public function exist($id) 
	{
		return $this->get($id) !== false;
	}
	
	static public function toHierarchyTree($rootId=Group::rootId, $countProducts=false)
	{
		$tree = array();
		$children = new ChildGroups($rootId);
		for ($i=0; $i<$children->size(); ++$i)
		{
			$child = $children->at($i);			
			
			$numOfProducts = null;
			if ($countProducts)
			{
				$products = new Products($child->id());
				$numOfProducts = $products->countAll();
			}

			$descendants = self::toHierarchyTree($child->id(), $countProducts);
			$tree[] = array(
				'id' => $child->id(),
				'code' => $child->code(),
				'name' => $child->name(),
				'link' => $child->link(),
				'depth' => $child->nestingDepth(),
				'hasProducts' => $child->hasProducts(),
				'numOfProducts' => $numOfProducts,
				'isFirstChild' => (bool)($i==0),
				'isLastChild' => (bool)($i==$children->size()-1),
				'hasChildren' => !empty($descendants)
			);
			$tree = array_merge($tree, $descendants);
		}
		return $tree;
	}
	
	static public function getPath($grId)
	{
		$path = array();
		$ancestors = new AncestorGroups($grId);
		foreach ($ancestors as $gr)
		{
			$path[] = array(
				'name' => $gr->name(),
				'link' => $gr->link(),
				'code' => $gr->code()
			);
		}
		$gr = new Group($grId);
		$path[] = array(
			'name' => $gr->name(),
			'link' => $gr->link(),
			'code' => $gr->code()
		);
		return $path;
	}
	
	protected function add(Group $item) 
	{
		parent::add($item);
	}
};


class ChildGroups
	extends Groups
{
	function __construct($parentId, $showDisabled=false)
	{
		$parentId = Group::toID($parentId);
		$q = createQuery("
			SELECT id
			FROM cf_groups
			WHERE (parent_id=:parentId OR (:parentId IS NULL AND parent_id IS NULL))
			". ($showDisabled ? '' : 'AND (hidden IS NULL OR hidden=0)') . "
			ORDER BY sort_order ASC
		");
		$q->setParam('parentId',$parentId);
		$q->execute();
		while ($q->fetch()) {
			$this->add(new Group($q->at('id')));
		}
		$q->close();
	}	
};


class DescendantGroups
	extends Groups
{
	function __construct($ancestorId, $showDisabled=false)
	{
		$this->addChildren($ancestorId, $showDisabled);
	}
	
	///array( parentId => array(child1, child2, ...) )
	public function toParentsArray()
	{
		$parents = array();
		foreach ($this->items as $child) 
		{
			$parentId = $child->parentId();
			if (!array_key_exists($parentId, $parents)) {
				$parents[$parentId] = array();
			}
			$parents[$parentId][] = $child;
		}
		return $parents;
	}
	
	
	private function addChildren($parentId, $showDisabled)
	{
		$children = new ChildGroups($parentId, $showDisabled);
		foreach ($children as $child) 
		{
			$this->add($child);
			$this->addChildren($child->id(), $showDisabled);
		}
	}
}


/** 
	[0]grandparent [1]parent 
*/
class AncestorGroups
	extends Groups
{
	function __construct($id)
	{
		$id = Group::toID($id);
		while ($id != Group::rootId)
		{                                            
			$gr = new Group($id);
			$id = $gr->parentId();
			if ($id != Group::rootId) {
				$gr = new Group($id);
				$this->add($gr);
			}                                         
		}
		$this->items = array_reverse($this->items);
	}
}



/**
	AncestorGroups + this group at the end
*/
class BranchGroups
	extends AncestorGroups
{
	function __construct($id)
	{
		parent::__construct($id);
		$this->add(new Group($id));
	}
}



class Manufacturer
{
	function __construct($id)
	{
		$this->id = self::toID($id);
		
		static $q = null;
		createStaticQuery($q,"
			SELECT name, code
			FROM cf_manufacturers
			WHERE id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No Manufacturer with id [{$this->id}]");
		}
		
		$this->name = $q->at('name');
		$this->code = trim($q->at('code'));
		
		$q->close();
	}
	
	public function id() 
	{
		return $this->id;
	}
	
	public function code() 
	{
		return strlen($this->code) ? $this->code : $this->id;
	}
	
	/**
		@param $id  string CODE or numeric ID
		@return numeric ID
	*/
	static public function toID($id)
	{
		if (is_numeric($id)) {
			return (int)$id;
		}

		static $q = null;
		createStaticQuery($q,"
			SELECT id FROM cf_manufacturers WHERE code=:code
		");
		$q->setParam('code',trim($id));
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("Invalid manufacturer code: [$id]");
		}
		$id = (int)$q->at('id');
		$q->close();
		
		return $id;
	}

	public function name() 
	{
		return $this->name;
	}
	
	public function descr() 
	{
		if (!is_null($this->descr)) {
			return $this->descr;
		}
		
		static $q = null;
		createStaticQuery($q,"
			SELECT descr
			FROM cf_manufacturers
			WHERE id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No Manufacturer with id [{$this->id}]");
		}
		$this->descr = $q->at('descr');
		$q->close();
		
		return $this->descr;
	}
	
	//image file URL (false if none)
	public function logo()
	{
		if (!is_null($this->logo)) {
			return $this->logo;
		}
		
		$logo = new Files('manufacturers');
		$logo->open($this->id, 'logo');
		if ($logo->fetch()) {
			$this->logo = $logo->current();
		}
		else {
			$this->logo = false;
		}

		return $this->logo;
	}
	
	
	private $id = null;
	private $code = null;
	private $name = '';
	private $descr = null;
	private $logo = null;
};



class ManufacturersInGroup
	extends ArrayOfItems
{
	function __construct($groupId)
	{
		$groupId = Group::toID($groupId);
		$descendants = new DescendantGroups($groupId);
		$IDs = $descendants->IDs();
		$IDs[] = $groupId;
		$q = createQuery("
			SELECT DISTINCT cf_products.manufacturer_id
			FROM cf_products
			INNER JOIN cf_products_in_groups ON cf_products.id=cf_products_in_groups.product_id
			INNER JOIN cf_manufacturers ON cf_products.manufacturer_id = cf_manufacturers.id
			WHERE cf_products_in_groups.group_id IN (".implode(',',$IDs).")
			ORDER BY cf_manufacturers.name
		");
		$q->execute();
		while ($q->fetch()) 
		{
			$this->add( new Manufacturer($q->at('manufacturer_id')) );
		}
		$q->close();
	}
};



class ProductGroups
	extends Groups
{
	function __construct($productId, $showDisabled=false)
	{
		$productId = Product::toID($productId);
		$q = createQuery("
			SELECT group_id
			FROM cf_products_in_groups
			INNER JOIN cf_groups ON cf_products_in_groups.group_id = cf_groups.id
			WHERE product_id=:productId
			". ($showDisabled ? '' : 'AND (cf_groups.hidden=0 OR cf_groups.hidden IS NULL)') . "
			ORDER BY cf_groups.sort_order ASC
		");
		$q->setParam('productId',$productId);
		$q->execute();
		while ($q->fetch()) 
		{
			$id = $q->at('group_id');
			if ($showDisabled || $this->isGroupEnable($id))
			{
				$this->add(new Group($id));
			}
		}
		$q->close();
	}
	
	
	private function isGroupEnable($id) 
	{
		$ancestors = new AncestorGroups($id);
		foreach ($ancestors as $ancestor)
		{
			if ($ancestor->hidden())
			{
				return false;
			}
		}
		return true;
	}
};


class Product
{
	function __construct($id)
	{
		$this->id = self::toID($id);

		static $qOwner = null;
		createStaticQuery($qOwner,"SELECT product_id FROM cf_product_variations WHERE id=:id");
		$qOwner->setParam('id',$this->id);
		$qOwner->execute();
		if ($qOwner->fetch()) {
			$this->mainID = $qOwner->at('product_id');
		}
		
		static $q = null;
		createStaticQuery($q,"
			SELECT 
				IFNULL(cf_manufacturers.code,cf_products.manufacturer_id) AS manufacturer_id, 
				cf_manufacturers.name AS manufacturer_name,
				IFNULL(CONCAT(cf_products.name,' ',cf_manufacturers.name,' ',cf_products.model),IFNULL(CONCAT(cf_products.name,' ',cf_manufacturers.name),IFNULL(CONCAT(cf_products.name,' ',cf_products.model),cf_products.name))) AS full_name,
				cf_products.name, 
				cf_products.model, 
				cf_products.price, 
				cf_product_states.id as state_id,
				cf_product_states.name as state,
				cf_products.code,
				cf_products.tags,
				cf_products.discount,
				cf_products.article,
				cf_products.tag_title,
				cf_products.tag_meta_keywords,
				cf_products.tag_meta_descr,
				cf_products.image1,
				cf_products.image1_text,
				cf_products.image2,
				cf_products.image2_text,
				cf_products.image3,
				cf_products.image3_text,
				cf_products.image4,
				cf_products.image4_text,
				cf_products.image5,
				cf_products.image5_text,
				cf_products.image6,
				cf_products.image6_text,
				cf_products.image7,
				cf_products.image7_text,
				cf_products.image8,
				cf_products.image8_text,
				cf_products.image9,
				cf_products.image9_text,
				cf_products.image10,
				cf_products.image10_text,
				cf_products.image11,
				cf_products.image11_text,
				cf_products.image12,
				cf_products.image12_text,
				cf_products.image13,
				cf_products.image13_text,
				cf_products.image14,
				cf_products.image14_text,
				cf_products.image15,
				cf_products.image15_text,
				cf_products.image16,
				cf_products.image16_text,
				cf_products.image17,
				cf_products.image17_text,
				cf_products.image18,
				cf_products.image18_text,
				cf_products.image19,
				cf_products.image19_text,
				cf_products.image20,
				cf_products.image20_text,
				cf_products.rating,
				seller_id,
				cf_sellers.name AS seller_name,
				cf_sellers.link AS seller_link,
				cf_products.link AS link
			FROM cf_products
			INNER JOIN cf_product_states ON cf_products.state_id=cf_product_states.id
			LEFT JOIN cf_manufacturers ON cf_products.manufacturer_id=cf_manufacturers.id
			LEFT JOIN cf_sellers ON cf_products.seller_id=cf_sellers.id
			WHERE cf_products.id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No product with id [{$this->id}]");
		}
		$this->manufacturerId = $q->at('manufacturer_id');
		$this->manufacturerName = $q->at('manufacturer_name');
		$this->fullName = $q->at('full_name');
		$this->name = $q->at('name');
		$this->model = $q->at('model');
		$this->price = $q->at('price');
		$this->stateId = $q->at('state_id');
		$this->state = $q->at('state');
		$this->code = trim($q->at('code'));
		$this->tags = explode(',',trim($q->at('tags')));
		$this->discount = trim($q->at('discount'));
		$this->article = $q->at('article');
		$this->image = $q->at('image1');
		$this->imageText = $q->at('image1_text');
		$this->tagTitle = $q->at('tag_title');
		$this->tagMetaKeywords = $q->at('tag_meta_keywords');
		$this->tagMetaDescr = $q->at('tag_meta_descr');
		$this->rating = $q->at('rating');
		$this->sellerId = $q->at('seller_id');
		$this->sellerName = $q->at('seller_name');
		$this->sellerLink = $q->at('seller_link');
		$this->link = $q->at('link');
		
		for ($i=1; $i<21; ++$i)
		{
			if ($q->at('image'.$i))
			{
				$this->images[] = $q->at('image'.$i);
				$this->imageTexts[] = $q->at('image'.$i.'_text');
			}
		}
		
		$q->close();
		
		if ($this->isVariationOf()) {
			$this->loadMainProduct();
		}
	}
	
	private function loadMainProduct()
	{
		if ($this->image) {
			return;		//variation has its own images
		}
		
		static $q = null;
		createStaticQuery($q,"
			SELECT 
				cf_products.image1,
				cf_products.image1_text,
				cf_products.image2,
				cf_products.image2_text,
				cf_products.image3,
				cf_products.image3_text,
				cf_products.image4,
				cf_products.image4_text,
				cf_products.image5,
				cf_products.image5_text,
				cf_products.image6,
				cf_products.image6_text,
				cf_products.image7,
				cf_products.image7_text,
				cf_products.image8,
				cf_products.image8_text,
				cf_products.image9,
				cf_products.image9_text,
				cf_products.image10,
				cf_products.image10_text,
				cf_products.image11,
				cf_products.image11_text,
				cf_products.image12,
				cf_products.image12_text,
				cf_products.image13,
				cf_products.image13_text,
				cf_products.image14,
				cf_products.image14_text,
				cf_products.image15,
				cf_products.image15_text,
				cf_products.image16,
				cf_products.image16_text,
				cf_products.image17,
				cf_products.image17_text,
				cf_products.image18,
				cf_products.image18_text,
				cf_products.image19,
				cf_products.image19_text,
				cf_products.image20,
				cf_products.image20_text
			FROM cf_products
			WHERE cf_products.id=:id
		");
		
		$q->setParam('id',$this->mainID);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No product with id [{$this->mainID}]");
		}
		$this->image = $q->at('image1');
		$this->imageText = $q->at('image1_text');
		
		for ($i=1; $i<21; ++$i)
		{
			if ($q->at($i))
			{
				$this->images[] = $q->at('image'.$i);
				$this->imageTexts[] = $q->at('image'.$i.'_text');
			}
		}
		$q->close();
	}

	public function id() 
	{
		return $this->id;
	}
	
	public function code() 
	{
		return strlen($this->code) ? $this->code : $this->id;
	}
	
	public function isVariationOf()
	{
		return $this->mainID;
	}
	
	/**
		@param $id  string CODE or numeric ID
		@return numeric ID
	*/
	static public function toID($id)
	{
		if (is_numeric($id)) {
			return (int)$id;
		}

		static $q = null;
		createStaticQuery($q,"
			SELECT id FROM cf_products WHERE code=:code
		");
		$q->setParam('code',trim($id));
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("Invalid product code: [$id]");
		}
		$id = (int)$q->at('id');
		$q->close();
		
		return $id;
	}

	public function name() 
	{
		return $this->name;
	}
	
	public function model() 
	{
		return $this->model;
	}
	
	public function manufacturerId() 
	{
		return $this->manufacturerId;
	}
	
	public function manufacturerName() 
	{
		return $this->manufacturerName;
	}
	
	/**
		@return  "name manufacturerName model"
	*/
	public function fullName()
	{
		return $this->fullName;
	}

	public function hasPrice()
	{
		return $this->price != 0;
	}
	
	/*
		@param  $type - one of 'ceil', 'floor', 'integral', 'fractional'
	*/                      
	public function price($type='')
	{
		$price = $this->price;
		if ($this->discount())
		{
			$discount = $this->discount();
			$matches = array();
			if (preg_match('/(\d+)\s*%/',$discount,$matches)) {
				$discount = (int)$matches[1];
				$price = $price - ($price / 100 * $discount);
			}
			else if (is_numeric($discount)) {
				$discount = (int)$discount;
				$price = max($this->price - $discount,0);
			}
		}
		$dot = strpos($price,'.');
		switch ($type) {
			case 'ceil': return (int)ceil($price);
			case 'floor': return (int)floor($price);
			case 'integral': return substr($price,0,$dot?$dot:strlen($price));
			case 'fractional': {
				$part = $dot ? substr($price,$dot+1) : '00';
				return strlen($part) == 2 ? $part : $part.'0';
			}
		}
		return $price;
	}
	
	public function priceNoDiscount()
	{
		return $this->price;
	}
	
	/** @return  product price (with discount) if it is set or min of options prices */
	public function minPrice()
	{
		if ($this->hasPrice())
		{
			return $this->price();
		}
		
		if (is_null($this->minPriceOption))
		{
			$this->minPriceOption = $this->getMinPriceOption();
		}
		if ($this->minPriceOption)
		{
			return $this->minPriceOption->price();
		}
		return 0;
	}

	public function hasDiscount()
	{
		return $this->hasPrice() && strlen($this->discount) != 0;
	}	
	
	/**	@return  discount as integer*/
	public function discount()
	{
		return $this->discount;
	}

	public function minPriceOptionId()
	{
		if ($this->hasPrice())
		{
			return 0;
		}
		
		if (is_null($this->minPriceOption))
		{
			$this->minPriceOption = $this->getMinPriceOption();
		}
		if ($this->minPriceOption)
		{
			return $this->minPriceOption->id();
		}
		return 0;
	}
	
	public function shortDescr() 
	{
		if (!is_null($this->shortDescr)) {
			return $this->shortDescr;
		}
		$this->shortDescr = self::loadShortDescr($this->id);
		if (!$this->shortDescr && $this->mainID) {
			$this->shortDescr = self::loadShortDescr($this->mainID);
		}
		return $this->shortDescr;
	}
	
	public function fullDescr() 
	{
		if (!is_null($this->fullDescr)) {
			return $this->fullDescr;
		}
		
		$this->fullDescr = query2var(
			"SELECT full_descr FROM cf_products WHERE id=:id",
			array('id'=>$this->id)
		);
		
		if (!$this->fullDescr && $this->mainID) {
			$this->fullDescr = query2var(
				"SELECT full_descr FROM cf_products WHERE id=:id",
				array('id'=>$this->mainID)
			);
		}
		return $this->fullDescr;
	}

	public function stateId()
	{
		return $this->stateId;
	}

	public function state()
	{
		return $this->state;
	}
	
	public function article()
	{
		return $this->article;
	}
	
	public function tagTitle()
	{
		return $this->tagTitle;
	}

	public function tagMetaKeywords()
	{
		return $this->tagMetaKeywords;
	}

	public function tagMetaDescr()
	{
		return $this->tagMetaDescr;
	}
	
	public function rating()
	{
		return $this->rating;
	}
   
	public function attributes($showDisabled=false, $skipNull=false)
	{
		if ($this->isVariationOf())	{
			return new ProductVariationAttributes($this->mainID, $this->id(), $showDisabled);
		}
		return new ProductAttributes($this->id(),$showDisabled,$skipNull);
	}
   
   /** @return  array of tag words */
   public function tags()
   {
      return $this->tags;
   }
   
	public function options()
	{
		return new ProductOptions($this->id());
	}
	
	public function clearAttributes()
	{
		self::delAttributes($this->id());
	}
	
	public function setAttributes(Attributes $attrs)
	{
		$this->clearAttributes();
		
		$availAttrs = new ProductAttributes($this->id(), true);
		foreach ($attrs as $attr)
		{
			if (!$availAttrs->exist($attr->id())) 
			{
				throw new \Exception("
					Attribute [{$attr->id()}] {$attr->name()}
					is not of product [{$this->id()}] {$this->name()}
				");
			}
			$this->addAttrValue($attr);
		}
	}
	
	public function sellerId() 
	{
		return $this->sellerId;
	}
	
	public function sellerName() 
	{
		return $this->sellerName;
	}
	
	public function sellerLink() 
	{
		return $this->sellerLink;
	}

	public function link() 
	{
		return $this->link;
	}
	
	/**
		@return URL of first image
	*/
	public function image()
	{
		return $this->image;
	}
	
	/**
		@return text of first image
	*/
	public function imageText()
	{
		return $this->imageText;
	}
	
	/**
		@return array of image URLs
	*/
	public function images()
	{
		return $this->images;
	}
	
	/**
		@return array of image texts
	*/
	public function imageTexts()
	{
		return $this->imageTexts;
	}
	
	public function isInGroup($grId)
	{
		$groups = new ProductGroups($this->id(),true);
		return $groups->exist($grId);
	}
	
	public function __toString()
	{
		return $this->name().' '.$this->manufacturerName().($this->model() ? (' '.$this->model()) : '');
	}
		
	
	static public function del($productId)
	{
		$productId = self::toID($productId);
		
		self::delFromGroups($productId);
		self::delAttributes($productId);
		self::delOptions($productId);
		
		static $q = null;
		createStaticQuery($q,"
			DELETE FROM cf_products
			WHERE id=:productId
		");
		$q->setParam('productId',$productId);
		$q->execute();
		$q->close();
	}


	
	static private function loadShortDescr($id) 
	{
		static $q = null;
		createStaticQuery($q,"SELECT short_descr FROM cf_products WHERE id=:id");
		$q->setParam('id',$id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No product with id [$id]");
		}
		$descr = trim($q->at('short_descr'));
		$q->close();
		return $descr;
	}
	
	static private function delFromGroups($productId)
	{
		static $q = null;
		createStaticQuery($q,"
			DELETE FROM cf_products_in_groups
			WHERE product_id=:productId
		");
		$q->setParam('productId',$productId);
		$q->execute();
		$q->close();
	}
	
	static private function delAttributes($productId)
	{
		static $q = null;
		createStaticQuery($q,"
			DELETE FROM cf_product_attributes
			WHERE product_id=:productId
		");
		$q->setParam('productId',$productId);
		$q->execute();
		$q->close();
	}

	static private function delOptions($productId)
	{
		$options = new ProductOptions($productId);
		foreach ($options as $option) {
			ProductOption::del($option->id());
		}
	}
	
	private function addAttrValue(Attribute $attr) 
	{
		static $q = null;
		createStaticQuery($q,"
			INSERT INTO cf_product_attributes
				(product_id, attribute_id, attribute_value)
			VALUES (:productId, :attrId, :value)
		");
		$q->setParam('productId', $this->id());
		$q->setParam('attrId', $attr->id());
		$q->setParam('value', $attr->value());
		$q->execute();
		$q->close();
	}	
	
	private function getMinPriceOption()
	{
		static $q = null;
		createStaticQuery($q,"
			SELECT id, price
			FROM cf_product_options
			WHERE product_id=:productId
			  AND price > 0
			LIMIT 1
		");
		$q->setParam('productId',$this->id());
		$q->execute();
		if ($q->fetch())
		{
			return new ProductOption($q->at('id'));
		}
		
		return false;
	}
	
	private $id = null;
	private $code = null;
	private $mainID = null;  //if this is a variation, ID of the main product
	private $name = '';
	private $model = '';
	private $fullName = '';
	private $manufacturerId = null;
	private $manufacturerName = null;
	private $shortDescr = null;
	private $fullDescr = null;
	private $price = null;
	private $minPriceOption = null;
	private $stateId;
	private $state;
	private $tags = array();
	private $discount = null;
	private $article = null;
	private $image = null;
	private $imageText = null;
	private	$tagTitle = null;
	private	$tagMetaKeywords = null;
	private	$tagMetaDescr = null;
	private $images = array();
	private $imageTexts = array();
	private	$rating = null;
	private $sellerId = null;
	private $sellerName = null;
	private $sellerLink = null;
	private $link = null;
}



class ProductOption
{
	function __construct($id)
	{
		$this->id = self::toID($id);
		
		static $q = null;
		createStaticQuery($q,"
			SELECT product_id, name, descr, price, code
			FROM cf_product_options
			WHERE id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No product option with id [{$this->id}]");
		}
		
		$this->productId = $q->at('product_id');
		$this->name = $q->at('name');
		$this->descr = $q->at('descr');
		$this->price = $q->at('price');
		$this->code = trim($q->at('code'));
		
		$q->close();
	}

	public function id() 
	{
		return $this->id;
	}
	
	public function code() 
	{
		return strlen($this->code) ? $this->code : $this->id;
	}
	
	/**
		@param $id  string CODE or numeric ID
		@return numeric ID
	*/
	static public function toID($id)
	{
		if (is_numeric($id)) {
			return (int)$id;
		}

		static $q = null;
		createStaticQuery($q,"
			SELECT id FROM cf_product_options WHERE code=:code
		");
		$q->setParam('code',trim($id));
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("Invalid product option code: [$id]");
		}
		$id = (int)$q->at('id');
		$q->close();
		
		return $id;
	}
	
	public function productId() 
	{
		return $this->productId;
	}

	public function name() 
	{
		return $this->name;
	}
	
	public function descr() 
	{
		return $this->descr;
	}
	
	public function hasPrice()
	{
		return $this->price != 0;
	}
	
	public function price()
	{
		return $this->price;
	}
	
	public function attributes($showDisabled=false)
	{
		return new ProductOptionAttributes($this->id(),$showDisabled);
	}
	
	public function clearAttributes()
	{
		self::delAttributes($this->id());
	}
	
	public function setAttributes(Attributes $attrs)
	{
		$this->clearAttributes();
		
		$availAttrs = new ProductAttributes($this->productId(), true);
		foreach ($attrs as $attr)
		{
			if (!$availAttrs->exist($attr->id())) 
			{
				throw new \Exception("
					Attribute [{$attr->id()}] {$attr->name()}
					is not of product [{$this->id()}] {$this->name()}
				");
			}
			if (!$attr->isNull()) 
			{
				$this->addAttrValue($attr);
			}
		}
	}
	
	static public function del($optionId)
	{
		self::delAttributes($optionId);
		
		static $q = null;
		createStaticQuery($q,"
			DELETE FROM cf_product_options
			WHERE id=:optionId
		");
		$q->setParam('optionId',$optionId);
		$q->execute();
		$q->close();
	}
	
	public function __toString() 
	{
		return "[{$this->id()}] {$this->name()} (price: {$this->price()})";
	}
	
	
	
	static private function delAttributes($optionId)
	{
		static $q = null;
		createStaticQuery($q,"
			DELETE FROM cf_product_options_attributes
			WHERE product_option_id=:optionId
		");
		$q->setParam('optionId',$optionId);
		$q->execute();
		$q->close();
	}
	
	private function addAttrValue(Attribute $attr) 
	{
		static $q = null;
		createStaticQuery($q,"
			INSERT INTO cf_product_options_attributes
				(product_option_id, attribute_id, attribute_value)
			VALUES 
				(:optionId, :attrId, :value)
		");
		$q->setParam('optionId', $this->id());
		$q->setParam('attrId', $attr->id());
		$q->setParam('value', $attr->value());
		$q->execute();
		$q->close();
	}
	
	private $id = null;	
	private $code = null;
	private $productId = null;
	private $name = '';
	private $descr = '';
	private $price = null;
};



class ProductOptions
	extends ArrayOfItems
{
	function __construct($productId)
	{
		$productId = Product::toID($productId);
		static $q = null;
		createStaticQuery($q,"
			SELECT id
			FROM cf_product_options
			WHERE product_id=:productId
			ORDER BY sort_order
		");
		$q->setParam('productId',$productId);
		$q->execute();
		while ($q->fetch()) 
		{
			$this->add(new ProductOption($q->at('id')));
		}
		$q->close();
	}
	
	/**
		@return array of ArrayOfItems of attributes each of them has at least one not-null value in an option
		length of the returned array = number of options
	*/
	public function getNotNullAttributes($groupId=false, $showDisabled=false)
	{
		$optionsAttrs = array();
		$notNullUniqueAttrsNums = array();
		$attrsCount = false;
		for ($i=0; $i<$this->size(); ++$i)
		{
			$uniqueAttrs = $groupId ? 
					new ProductOptionUniqueGroupAttributes($this->at($i)->id(), $groupId) : 
					new ProductOptionUniqueAttributes($this->at($i)->id(), $showDisabled);
					
			if (!$attrsCount) 
			{
				$attrsCount = $uniqueAttrs->size();
			}
			
			for ($j=0; $j<$attrsCount; ++$j)
			{
				$a = $uniqueAttrs->at($j);
				if (!$a->isNull())
				{
					$notNullUniqueAttrsNums[$j] = true;
				}
			}
			$optionsAttrs[] = $groupId ? 
					new ProductOptionGroupAttributes($this->at($i)->id(), $groupId) : 
					new ProductOptionAttributes($this->at($i)->id(), $showDisabled);
		}
		
		if (empty($optionsAttrs) || count($notNullUniqueAttrsNums)==$attrsCount) {	//no attributes or no null attributes
			return $optionsAttrs;
		}
		
		$nullUniqueAttrsNums = array();
		for ($j=0; $j<$attrsCount; ++$j)
		{
			if (!array_key_exists($j,$notNullUniqueAttrsNums))
			{
				$nullUniqueAttrsNums[$j] = true;
			}
		}
		
		foreach (array_keys($nullUniqueAttrsNums) as $n)
		{
			foreach ($optionsAttrs as $attrs)
			{
				$attrs->erase($n);
			}
		}

		return $optionsAttrs;
	}
};



class LimitedArray
	extends ArrayOfItems
{
	protected function __construct($sql, $skip=null, $limit=null)
	{
		$this->sql = $sql;
		$this->limitedSql = ($skip || $limit) ? limitSelect($sql, $limit, $skip) : $sql;
	}

	public function countAll()
	{
		return DB::countRecords($this->sql);
	}
	
	protected function SQL()
	{
		return $this->sql;
	}
	
	protected function limitedSQL()
	{
		return $this->limitedSql;
	}
	
	private $sql = null;
	private $limitedSql = null;
}



class Products
	extends ArrayOfItems
{
	/**
		Get products that belong to ANY group in the set
		@param  $groupIDs          array of IDs/CODEs or sigle number ID/string CODE
	*/
	function __construct($groupIDs=false)
	{
		if (!$groupIDs)
		{
			$allGroups = new DescendantGroups(Group::rootId,true);
			$groupIDs = $allGroups->IDs();
		}
		
		$this->sqlWhere['(cf_products.enabled_from IS NULL OR NOW() >= cf_products.enabled_from)'] = 1;
		$this->sqlWhere['(cf_products.enabled_to IS NULL OR NOW() < cf_products.enabled_to)'] = 1;

		$this->parseIDs($groupIDs);
		$this->sqlJoin["INNER JOIN cf_products_in_groups ON cf_products.id = cf_products_in_groups.product_id"] = 1;
		$this->sqlWhere["group_id IN (".implode(',',$groupIDs).")"] = 1;
	}
	
	/**
		Get only products that belong to EVERY group in the set
		@param  $groupIDs  array of IDs/CODEs or sigle number ID/string CODE
	*/
	public function mustBeIn($groupIDs)
	{
		$this->parseIDs($groupIDs);
		foreach ($groupIDs as $id)
		{
			$this->sqlWhere["EXISTS(SELECT * FROM cf_products_in_groups WHERE product_id=cf_products.id AND group_id=$id)"] = 1;
		}
	}
	
	public function filterByManufacturers($IDs)
	{
		$IDs = is_array($IDs) ? $IDs : ($IDs ? array($IDs) : array());
		foreach ($IDs as $i => $id) {
			$IDs[$i] = Manufacturer::toID($id);
		}
		if (!empty($IDs)) {
			$this->sqlWhere['manufacturer_id IN ('.implode(',',$IDs).')'] = 1;
		}
	}

	public function filterByAttributes($attributes)
	{
		foreach ($attributes as $id => $val)
		{
			if (empty($val)) {
				continue;
			}
			$id = Attribute::toID($id);
			$a = new Attribute($id);
			$this->sqlJoin["INNER JOIN cf_product_attributes a$id ON cf_products.id = a$id.product_id"] = 1;
			$this->sqlWhere["a$id.attribute_id=$id"] = 1;
			if ($a->isList())
			{
				$this->sqlJoin["INNER JOIN cf_attribute_values a{$id}vals ON CAST(a$id.attribute_value AS UNSIGNED) = a{$id}vals.id"] = 1;
				if (!is_array($val)) {
					$val = array($val);
				}
				$val = array_filter($val,'intval');
				$this->sqlWhere["a{$id}vals.id IN (".implode(',',$val).')'] = 1;
			}
			elseif ($a->type() == 'String')
			{
				$this->sqlWhere["a$id.attribute_value=:a$id"] = 1;
				$this->params["a$id"] = $val;
			}
			elseif ($a->type() == 'Integer')
			{
				if (is_array($val))
				{
					reset($val);
					if (current($val) !== false)
					{
						$this->sqlWhere["CAST(a$id.attribute_value AS UNSIGNED)>=:a{$id}1"] = 1;
						$this->params["a{$id}1"] = (int)current($val);
						if (next($val) !== false)
						{
							$this->sqlWhere["CAST(a$id.attribute_value AS UNSIGNED)<=:a{$id}2"] = 1;
							$this->params["a{$id}2"] = (int)current($val);
						}
					}
				}
				else
				{
					$this->sqlWhere["CAST(a$id.attribute_value AS UNSIGNED)=:a$id"] = 1;
					$this->params["a$id"] = (int)$val;
				}
			}
		}
	}
	
	public function findByTag($tag)
	{
		$this->sqlWhere["FIND_IN_SET(:tag,tags)"] = 1;
		$this->params['tag'] = $tag;
	}
	
	public function filterBySellers($IDs)
	{
		$IDs = is_array($IDs) ? $IDs : ($IDs ? array($IDs) : array());
		if (!empty($IDs)) {
			$this->sqlWhere['seller_id IN ('.implode(',',$IDs).')'] = 1;
		}
	}
	
	/**
		@param $search  a string or an array of strings (in this case search for EVERY string in the array)
		@param $lookInTags  also search in product tags
	*/
	public function find($search, $lookInTags=false)
	{
		$search = is_array($search) ? $search : array($search);

		$sql = '';
		$i = 0;
		foreach ($search as $s)
		{
			$sql = "(
				name LIKE CONCAT('%',:search{$i},'%')
				OR model LIKE CONCAT('%',:search{$i},'%')
				OR short_descr LIKE CONCAT('%',:search{$i},'%')
				OR full_descr LIKE CONCAT('%',:search{$i},'%')
			";
			if ($lookInTags) {
				$sql .= "OR tags LIKE CONCAT('%',:search{$i},'%')";
			}
			$sql .= ')';
			
			$this->sqlWhere[$sql] = 1;
			
			$this->params["search{$i}"] = trim($s);
		
			++$i;
		}
		return $this;
	}
	
	/**
		@param $sort   array('sort_order|name|model|id|price'=>'ASC|DESC',...)
						OR if $sort is numeric it is treated as a seed for RAND() and result list is sorted by RAND($sort)
	*/
	public function sortBy($sort)
	{
		if (is_array($sort))	//fields passed
		{
			foreach ($sort as $f => $dir)
			{
				$this->sqlSort["$f $dir"] = 1;
			}
		}
		else					//seed passed
		{
			$seed = (int)$sort;
			$this->sqlSort["RAND($seed)"] = 1;
		}
	}
	
	public function countAll()
	{
		return DB::countRecords($this->buildSQL(), $this->params);
	}
	
	public function getList($skip=null, $limit=null)
	{
		//default sorting
		if (empty($this->sqlSort))
		{
			$this->sqlSort['cf_products_in_groups.sort_order ASC'] = 1;
			$this->sqlSort['cf_products.name ASC'] = 1;
		}
	

		$sql = $this->buildSQL();
		if ($skip || $limit) 
		{
			$sql = limitSelect($sql, $limit, $skip);
		}
		
		$q = createQuery($sql, $this->params);
		$q->execute();
		while ($q->fetch(DB_FETCH_NUM)) 
		{
			$this->add(new Product($q->at(0)));
		}
		
		return $this->items;
	}
	
	public function prev($productId, $count=1, &$bof=false)
	{
		$rowNum = $this->getRowNumber($productId);
		$bof = $rowNum <= $count+1;
		$q = createQuery(limitSelect($this->buildSQL(), $count, max($rowNum-$count-1,0)));
		$q->execute();
		while ($q->fetch(DB_FETCH_NUM)) 
		{
			$this->add( new Product($q->at(0)) );
		}
		return $this->items;
	}
	
	public function next($productId, $count=1, &$eof=false)
	{
		$rowNum = $this->getRowNumber($productId);
		
		$q = createQuery(limitSelect($this->buildSQL(), ++$count, $rowNum));
		$q->execute();
		while (--$count>0 && $q->fetch(DB_FETCH_NUM)) 
		{
			$this->add( new Product($q->at(0)) );
		}
		$eof = $count || !$q->fetch();
		return $this->items;
	}

	
	public function IDs()
	{
		$ids = array();
		foreach ($this->items as $product)
		{
			$ids[] = $product->id();
		}
		return $ids;
	}
	

	
	private function buildSQL()
	{
		$sql = '
			SELECT DISTINCT cf_products.id
			FROM cf_products '
			.implode(' ',array_keys($this->sqlJoin)).
			' WHERE '.implode(' AND ',array_keys($this->sqlWhere));
			
		if (!empty($this->sqlSort))
		{
			$sql .= ' ORDER BY '.implode(',',array_keys($this->sqlSort));
		}
		return $sql;
	}
	
	private function parseIDs(&$ids)
	{
		if (is_string($ids)) {
			$ids = explode(' ',$ids);
		} 
		elseif (is_int($ids)) {
			$ids = array($ids);
		}
		elseif (!is_array($ids)) {
			$ids = array();
		}
		
		foreach ($ids as $i => $id) {
			$ids[$i] = Group::toID($id);
		}
	}
	
	private function getRowNumber($productId)
	{
		$productId = Product::toID($productId);
		$q = createQuery("
			SELECT @rownum:=@rownum+1 as rownum, id FROM ({$this->buildSQL()}) products, (SELECT @rownum:=0) r
		");
		$q->execute();
		while ($q->fetch()) 
		{
			if ($q->at('id') == $productId) 
			{
				return $q->at('rownum');
			}
		}
		return false;
	}
	
	protected $sqlJoin = array();
	protected $sqlWhere = array();
	protected $sqlSort = array();
	protected $params = array();
}


/**
	Products that belong to this group or any of it's descendants
*/
class ProductsInSubtree
	extends Products
{
	/** @param $groupID  single group ID or CODE */
	function __construct($groupID, $showDisabled=false)
	{
		$descendants = new DescendantGroups($groupID, $showDisabled);
		$IDs = $descendants->IDs();
		if ($groupID) 
		{
			$IDs[] = Group::toID($groupID);
		}
		
		parent::__construct($IDs);
	}
}


class RelatedProducts
      extends ProductsInSubtree
{
   function __construct($productId, $groupID=Group::rootId, $showDisabled=false)
   {
		parent::__construct($groupID, $showDisabled);
		
		$productId = Product::toID($productId);
		
		$this->sqlJoin["INNER JOIN cf_related_products ON cf_products.id = cf_related_products.related_product_id"] = 1;
		$this->sqlWhere["cf_related_products.product_id=$productId"] = 1;
		$this->sqlSort['cf_related_products.sort_order ASC'] = 1;
   }
}




class AttributeTypes
{
	const STRING = 'String';
	const INTEGER = 'Integer';
	const PRICE = 'Price';
	const FLOAT1 = 'Float1';
	const FLOAT2 = 'Float2';
	const FLOAT3 = 'Float3';
}


class Attribute
{
	function __construct($id)
	{
		$this->id = self::toID($id);
		
		static $q = null;
		createStaticQuery($q,"
			SELECT name, type_id, code
			FROM cf_attributes
			WHERE id=:id
		");
		$q->setParam('id',$this->id);
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("No Attribute with id [{$this->id}]");
		}
		$this->name = $q->at('name');
		$this->type = $q->at('type_id');
		$this->code = trim($q->at('code'));
		$q->close();
		
		$this->loadAllowedValues();
	}
	
	public function id()
	{
		return $this->id;
	}
	
	public function code() 
	{
		return strlen($this->code) ? $this->code : $this->id;
	}
	
	/**
		@param $id  string CODE or numeric ID
		@return numeric ID
	*/
	static public function toID($id)
	{
		if (is_numeric($id)) {
			return (int)$id;
		}

		static $q = null;
		createStaticQuery($q,"
			SELECT id FROM cf_attributes WHERE code=:code
		");
		$q->setParam('code',trim($id));
		$q->execute();
		if (!$q->fetch()) {
			throw new \Exception("Invalid attribute code: [$id]");
		}
		$id = (int)$q->at('id');
		$q->close();
		
		return $id;
	}
	
	public function name() 
	{
		return $this->name;
	}
	
	public function value()
	{
		return $this->value;
	}
	
	public function isNull()
	{
		return !(bool)$this->value;
	}
	
	/**
		@return  a member of AttributeTypes
	*/
	public function type()
	{
		return $this->type;
	}
	
	public function length()
	{
		if ($this->type() == 'String') {
			return 1024;
		}
		elseif ($this->type() == 'Integer') {
			return 10;
		}
		elseif ($this->type() == AttributeTypes::PRICE) {
			return 12;
		}
		elseif ($this->type() == AttributeTypes::FLOAT1) {
			return 11;
		}
		elseif ($this->type() == AttributeTypes::FLOAT2) {
			return 12;
		}
		elseif ($this->type() == AttributeTypes::FLOAT3) {
			return 13;
		}
		return 0;
	}
	
	public function scale()
	{
		if ($this->type() == AttributeTypes::PRICE) {
			return 2;
		}
		elseif ($this->type() == AttributeTypes::FLOAT1) {
			return 1;
		}
		elseif ($this->type() == AttributeTypes::FLOAT2) {
			return 2;
		}
		elseif ($this->type() == AttributeTypes::FLOAT3) {
			return 3;
		}
		return 0;
	}
	
	public function isList()
	{
		return !empty($this->valuesList);
	}

	///array of pairs (0=>id 1=>value)
	public function allowedValues()
	{
		if (!$this->isList()) {
			return false;
		}
		return $this->valuesList;
	}
	
	/** when list - should be ID of value record */
	public function setValue($val)
	{
		if (!is_null($val)) 
		{
			if ($this->isList()) {
				$val = (int)$val;
			}
			else {
				$val = $this->convertValue($val);
			}
		}
		
		if (!$this->isValueAllowed($val)) {
			throw new \Exception("
				Invalid value ($val) for attribute [{$this->id()}] {$this->name()}
			");
		}
		
		$this->value = $val;
	}
	
	public function minValue()
	{
		$val = null;
		
		static $q = null;
		createStaticQuery($q,"
			SELECT MIN(CAST(attribute_value AS UNSIGNED))
			FROM cf_product_attributes
			WHERE attribute_id=:id 
			  AND attribute_value IS NOT NULL
		");
		$q->setParam('id',$this->id());
		$q->execute();
		if ($q->fetch(DB_FETCH_NUM)) {
			$val = $q->at(0);
		}
		$q->close();
		if (is_null($val)) 
		{
			$val = 0;
		}
		return $val;
	}
	
	public function maxValue()
	{
		$val = null;
		
		static $q = null;
		createStaticQuery($q,"
			SELECT MAX(CAST(attribute_value AS UNSIGNED))
			FROM cf_product_attributes
			WHERE attribute_id=:id 
		");
		$q->setParam('id',$this->id());
		$q->execute();
		if ($q->fetch(DB_FETCH_NUM)) {
			$val = $q->at(0);
		}
		$q->close();
		return $val;
	}

	public function asString()
	{
		if ($this->isList() && !$this->isNull()) 
		{
			foreach ($this->allowedValues() as $pair) 
			{
				if ($pair[0] == $this->value()) {
					return $pair[1];
				}
			}
			throw new \Exception("
				Value [{$this->value()}] of attribute [{$this->name()}]
				is not in list of possible values
			");
		}
		return (string)$this->value();
	}
	
	public function __toString() 
	{
       return $this->asString();
	}
   
   
   private function loadAllowedValues()
   {   
		static $q = null;
		createStaticQuery($q,"
			SELECT id, value
			FROM cf_attribute_values
			WHERE attribute_id = :id
			ORDER BY sort_order ASC
		");
		$q->setParam('id',$this->id());
		$q->execute();
		while ($q->fetch()) {
			$this->valuesList[] = array(
				(int)$q->at('id'),
				$this->convertValue($q->at('value'))
			);
		}
		$q->close();
	}
   
   
   private function isValueAllowed($val)
   {
		if ($this->isList() && $val)
		{
			foreach ($this->valuesList as $allowedVal) {
				if ($val == $allowedVal[0]) {
					return true;
				}
			}
			return false;
		}
		return true;
   }
   
   private function convertValue($val) 
   {
		if ($this->type() == AttributeTypes::INTEGER) {
			return (int)$val;
		}
		return (string)$val;
   }

	
	private $id = null;
	private $code = null;
	private $name = '';
	private $value = null;
	private $type = null;
	private $valuesList = array();
};




/** 
	set of attributes, every unique attribute included only once
*/
class Attributes
	extends ArrayOfItems
{
	/**
		@param $ids  array of CODEs/IDs or string CODEs/IDs separated by any whitespace character(s) or a single CODE/ID
	*/
	function __construct($ids=array(), $skipNull=false)
	{
		$this->skipNull = $skipNull;
		
		if (is_string($ids)) {
			$ids = preg_split("/\s+/", $ids, -1, PREG_SPLIT_NO_EMPTY);
		} 
		elseif (is_int($ids)) {
			$ids = array($ids);
		}
		elseif (!is_array($ids)) {
			$ids = array();
		}
		
		foreach ($ids as $id) {
			$this->add( new Attribute($id) );
		}
	}	
		

	public function get($id) 
	{
		$id = Attribute::toID($id);
		foreach ($this->items as $item) 
		{
			if ($item->id() == $id) {
				return $item;
			}
		}
		return false;
	}
	
	public function exist($id) 
	{
		return !($this->get($id) === false);
	}
	
	public function IDs()
	{
		$ids = array();
		foreach ($this->items as $attr)
		{
			$ids[] = $attr->id();
		}
		return $ids;
	}
	
	public function allNull()
	{
		foreach ($this->items as $item) 
		{
			if (!$item->isNull()) {
				return false;
			}
		}
		return true;
	}
	
	public function values()
	{
		$values = array();
		foreach ($this->items as $item) {
			$v = array(
				'name' => $item->name(),
				'value' => $item->value(),
				'descr' => $item->isList() ? $item->asString() : $item->value()
			);
			$values[$item->id()] = $v;
			if ($item->code()) {
				$values[$item->code()] = $v;
			}
		}
		return $values;
	}


	protected function create($id)
	{
		return new Attribute($id);
	}
	
	protected function add(Attribute $item) 
	{
		if (!$this->exist($item->id()) && (!$this->skipNull || ($this->skipNull && !$item->isNull()))) 
		{
			parent::add($item);
			return true;
		}
		return false;
	}
	
	protected $skipNull = false;
};



class ProductAttribute
	extends Attribute
{
	function __construct($attrId, $productId)
	{
		$attrId = Attribute::toID($attrId);
		$productId = Product::toID($productId);
		
		parent::__construct($attrId);

		static $q = null;
		createStaticQuery($q,"
			SELECT attribute_value
			FROM cf_product_attributes
			WHERE product_id=:productId AND attribute_id=:attrId
		");
		$q->setParam('productId', $productId);
		$q->setParam('attrId', $attrId);
		$q->execute();
		if ($q->fetch()) {
			$this->setValue($q->at('attribute_value'));
		}
		$q->close();
	}
}


/**
	Attributes of one or many groups
*/
class GroupAttributes
	extends Attributes
{
	/**
		@param $groupId  may be an array of group IDs or CODEs
	*/
	function __construct($groupId, $skipNull=false)
	{
		$this->skipNull = $skipNull;
		
		$groupIds = is_array($groupId) ? $groupId : ($groupId ? array($groupId) : array());
		
		foreach ($groupIds as $id) 
		{
			$branch = new BranchGroups( Group::toID($id) );
			foreach ($branch as $ancestor) 
			{
				$this->loadAttrsIDandPos( $ancestor->id() );
			}
		}
		
		asort($this->id2pos);
		
		foreach ($this->id2pos as $id => $pos)
		{
			$this->add( $this->create( $id ) );
		}
	}
	
	
	private function loadAttrsIDandPos($groupId)
	{
		static $q = null;
		createStaticQuery($q,"
			SELECT attribute_id, sort_order
			FROM cf_group_attributes
			WHERE group_id=:groupId
			ORDER BY sort_order ASC
		");
		$q->setParam('groupId',$groupId);
		$q->execute();
		while ($q->fetch()) 
		{
			$this->id2pos[$q->at('attribute_id')] = $q->at('sort_order');
		}
		$q->close();	
	}
	
	private $id2pos = array();
}




class ProductGroupAttributes
	extends GroupAttributes
{
	/**
		@param $groupId  may be an array of group IDs or CODEs
	*/
	function __construct($productId, $groupId, $skipNull=false)
	{
		$this->productId = Product::toID($productId);
		parent::__construct($groupId,$skipNull);
	}
	
	protected function create($id)
	{
		return new ProductAttribute($id, $this->productId);
	}
	
	protected $productId = null;
}


/**
	Product attributes in ALL product groups
*/
class ProductAttributes
	extends ProductGroupAttributes
{
	function __construct($productId, $showDisabled=false, $skipNull=false)
	{
		$allGroups = new ProductGroups($productId, $showDisabled);
		parent::__construct($productId, $allGroups->IDs(), $skipNull);
	}
}

/**
	Takes product attribute value if variation attribute value is not set
*/
class ProductVariationAttributes
	extends ProductAttributes
{
	function __construct($productId, $variationId, $showDisabled=false, $skipNull=false)
	{
		$this->variationId = Product::toID($variationId);
		parent::__construct($productId, $showDisabled, $skipNull);
	}
	
	protected function create($id)
	{
		$a = new ProductAttribute($id, $this->variationId);
		return $a->isNull() ? new ProductAttribute($id, $this->productId) : $a;
	}
	
	protected $variationId = null;
}

class ProductOptionAttribute
	extends Attribute
{
	function __construct($attrId, $optionId)
	{
		$attrId = Attribute::toID($attrId);
		$optionId = ProductOption::toID($optionId);
		
		parent::__construct($attrId);
		
		static $q = null;
		createStaticQuery($q,"
			SELECT attribute_value
			FROM cf_product_options_attributes
			WHERE product_option_id=:optionId AND attribute_id=:attrId
		");
		$q->setParam('optionId',$optionId);
		$q->setParam('attrId',$attrId);
		$q->execute();
		if ($q->fetch()) {
			$this->setValue($q->at('attribute_value'));
		}
		$q->close();
	}
}

/**
	Takes product option attributes values from DB
*/
class ProductOptionUniqueGroupAttributes
	extends ProductGroupAttributes
{
	/**
		@param $groupId  may be an array of group IDs or CODEs
	*/
	function __construct($optionId, $groupId)
	{
		$option = new ProductOption($optionId);
		$this->optionId = $option->id();
		parent::__construct($option->productId(), $groupId);
	}
	
	protected function create($id)
	{
		return new ProductOptionAttribute($id, $this->optionId);
	}
	
	
	protected $optionId = null;
}



class ProductOptionUniqueAttributes
	extends ProductOptionUniqueGroupAttributes
{
	function __construct($optionId, $showDisabled=false)
	{
		$option = new ProductOption($optionId);
		$allGroups = new ProductGroups($option->productId(), $showDisabled);
		parent::__construct($option->id(), $allGroups->IDs());
	}
}



/**
	Takes product attribute value if option attribute value is not set
*/
class ProductOptionGroupAttributes
	extends ProductOptionUniqueGroupAttributes
{
	function __construct($optionId, $groupId)
	{
		parent::__construct($optionId, $groupId);
	}
	
	protected function create($id)
	{
		return ProductGroupAttributes::create($id);		//create product attribute
	}
	
	protected function add(Attribute $productAttr) 
	{
		$optionAttr = new ProductOptionAttribute($productAttr->id(), $this->optionId);
		GroupAttributes::add($optionAttr->isNull() ? $productAttr : $optionAttr);	//override with option attribute it is not null
	}
}


/**
	Takes product attribute value if option attribute value is not set
*/
class ProductOptionAttributes
	extends ProductOptionGroupAttributes
{
	function __construct($optionId, $showDisabled=false)
	{
		$option = new ProductOption($optionId);
		$allGroups = new ProductGroups($option->productId(), $showDisabled);
		parent::__construct($option->id(), $allGroups->IDs());
	}
}



/**
	Each review is array with fields: 'user', 'date', 'review'
*/
class ProductReviews
	extends LimitedArray
{
	function __construct($productId, $skip=null, $limit=null)
	{
		$productId = Product::toID($productId);
		$sql = "
			SELECT cf_users.name AS user_name, dt, review
			FROM cf_product_reviews
			INNER JOIN cf_users ON cf_product_reviews.user_id=cf_users.id
			WHERE product_id=$productId
			ORDER BY dt DESC, cf_product_reviews.id DESC
		";
		parent::__construct($sql,$skip,$limit);
		
		$q = createQuery($this->limitedSql());
		$q->execute();
		while ($q->fetch()) 
		{
			$this->add(
				array(
					'user' => $q->at('user_name'),
					'date' => $q->at('dt'),
					'review' => $q->at('review')
				)
			);
		}
		$q->close();
	}

	static public function create($productId, $userId, $review)
	{
		return 
			execQuery(
				"INSERT INTO cf_product_reviews (product_id,user_id,review,dt) VALUES(:productId,:userId,:review,NOW())",
				array('productId'=>Product::toID($productId), 'userId'=>$userId, 'review'=>$review)
			);
	}
}





class Customer
{
	static public function get($uid)
	{
		return query2array('
			SELECT cf_customers.id AS id, phone, address
			FROM cf_customers 
			INNER JOIN cf_customer_addresses ON default_address_id=cf_customer_addresses.id
			WHERE user_id=:uid',
			array('uid'=>$uid)
		);
	}
	
	static public function create($userId, $phone='', $address='')
	{
		$custId = execQuery(
			'INSERT INTO cf_customers (user_id,phone) VALUES(:uid, :phone)',
			array('uid'=>$userId, 'phone'=>$phone)
		);
		$addressId = execQuery(
			'INSERT INTO cf_customer_addresses (customer_id, address) VALUES(:cid, :address)',
			array('cid'=>$custId, 'address'=>$address)
		);
		execQuery('UPDATE cf_customers SET default_address_id=:aid',array('aid'=>$addressId));
		
		return $custId;
	}
}





abstract class ShopExport
{
	public function export($groupId=null)
	{
		$this->exportGroups($groupId);
		$this->exportProducts($groupId);
	}
	
	abstract protected function exportGroup($group);
	abstract protected function exportProduct($product, $groupId, $manufacturer, $option=null);


	
	private function exportGroups($parentId)
	{
		if (!is_null($parentId))
		{
			$this->exportGroup( new Group($parentId) );
		}
		
		$groups = new ChildGroups($parentId);
		foreach ($groups as $group)
		{	
			$this->exportGroups($group->id());
		}
	}
	
	private function exportProducts($rootId)
	{
		$groups = new DescendantGroups($rootId);
		$ids = array();
		foreach ($groups as $group)
		{
			$ids[] = $group->id();
		}
		if ($rootId)
		{
			$ids[] = $rootId;
		}
		
		$q = createQuery("
			SELECT product_id, group_id
			FROM cf_products_in_groups
			INNER JOIN cf_groups ON cf_products_in_groups.group_id = cf_groups.id
			WHERE group_id IN (" . implode(',',$ids) . ")
			GROUP BY product_id
			ORDER BY cf_groups.sort_order, cf_products_in_groups.sort_order
		");
		$q->execute();
		
		while ($q->fetch())
		{
			$productId = $q->at('product_id');
			$groupId = $q->at('group_id');
			$product = new Product($productId);
			$inGroups = new ProductGroups($productId);

			$manufacturer = new Manufacturer($product->manufacturerId());
			
			$this->exportProduct($product, $groupId, $manufacturer);
			
			$options = new ProductOptions($productId);
			foreach ($options as $option)
			{ 
				$this->exportProduct($product, $groupId, $manufacturer, $option);
			}
		}
		
		$q->close();
	}
}



class CompareProducts 
	extends ArrayOfItems
{
	/**
		@param $productsIDs  array of product IDs to compare
		@param $compareBy    array of attribute IDs to include in the result compare list
	*/
	function __construct($productsIDs, $compareBy)
	{
		$prodAttributes = array();
		foreach ($productsIDs as $prodId)
		{
			$product = new Product($prodId);
			$prodAttributes[$product->id()] = $product->attributes();
			$this->items[$product->id()] = array();
		}
		
		foreach ($compareBy as $attrId)
		{
			foreach ($prodAttributes as $prodId => $attrs)
			{
				$attr = new Attribute($attrId);
				if ($this->isSetInAtLeastOne($prodAttributes,$attr->id()))
				{
					$this->items[$prodId][$attr->id()] = $attrs->get($attr->id());
					$this->fullList[$attr->id()] = $attr;
					if (!$this->isEqualForAll($prodAttributes,$attr->id()))
					{
						$this->listOfDifferences[$attr->id()] = $attr;
					}
				}
			}
		}
	}
	
	public function fullList()
	{
		return $this->fullList;
	}
	
	public function listOfDifferences()
	{
		return $this->listOfDifferences;
	}


	
	private function isSetInAtLeastOne($prodAttributes, $attrId)
	{
		foreach ($prodAttributes as $attrs)
		{
			if ($attrs->exist($attrId))
			{
				if (!$attrs->get($attrId)->isNull())
				{
					return true;
				}
			}
		}
		return false;
	}
	
	private function isEqualForAll($prodAttributes, $attrId)
	{
		$v = null;
		$first = true;
		foreach ($prodAttributes as $attrs)
		{
			if ($first) 
			{
				$first = false;
				$v = $attrs->get($attrId)->value();
			}
			else if ($v != $attrs->get($attrId)->value())
			{
				return false;
			}
		}
		return true;
	}
	
	private $fullList = array();
	private $listOfDifferences = array();
}

?>